import wcwidth from 'wcwidth'

function isDigit(char) {
  return '0123456789'.indexOf(char) >= 0
}

export const ESC = '\x1b'

// Attributes
export const A_RESET =   0
export const A_BRIGHT =  1
export const A_DIM =     2
export const A_INVERT =  7

// Colors
export const C_BLACK =    30
export const C_RED =      31
export const C_GREEN =    32
export const C_YELLOW =   33
export const C_BLUE =     34
export const C_MAGENTA =  35
export const C_CYAN =     36
export const C_WHITE =    37
export const C_RESET =    39

export function clearScreen() {
  // Clears the screen, removing any characters displayed, and resets the
  // cursor position.

  return `${ESC}[2J`
}

export function moveCursorRaw(line, col) {
  // Moves the cursor to the given line and column on the screen.
  // Returns the pure ANSI code, with no modification to line or col.

  return `${ESC}[${line};${col}H`
}

export function moveCursor(line, col) {
  // Moves the cursor to the given line and column on the screen.
  // Note that since in JavaScript indexes start at 0, but in ANSI codes
  // the top left of the screen is (1, 1), this function adjusts the
  // arguments to act as if the top left of the screen is (0, 0).

  return `${ESC}[${line + 1};${col + 1}H`
}

export function cleanCursor() {
  // A combination of codes that generally cleans up the cursor.

  return resetAttributes() +
    stopTrackingMouse() +
    showCursor()
}

export function hideCursor() {
  // Makes the cursor invisible.

  return `${ESC}[?25l`
}

export function showCursor() {
  // Makes the cursor visible.

  return `${ESC}[?25h`
}

export function resetAttributes() {
  // Resets all attributes, including text decorations, foreground and
  // background color.

  return `${ESC}[0m`
}

export function setAttributes(attrs) {
  // Set some raw attributes. See the attributes section of the ansi.js
  // source code for attributes that can be used with this; A_RESET resets
  // all attributes.

  return `${ESC}[${attrs.join(';')}m`
}

export function setForeground(color) {
  // Sets the foreground color to print text with. See C_(COLOR) for colors
  // that can be used with this; C_RESET resets the foreground.
  //
  // If null or undefined is passed, this function will return a blank
  // string (no ANSI escape codes).

  if (typeof color === 'undefined' || color === null) {
    return ''
  }

  return setAttributes([color])
}

export function setBackground(color) {
  // Sets the background color to print text with. Accepts the same arguments
  // as setForeground (C_(COLOR), C_RESET, etc).
  //
  // Note that attributes such as A_BRIGHT and A_DIM apply apply to only the
  // foreground, not the background. To set a bright or dim background, you
  // can set the appropriate color as the foreground and then invert.

  if (typeof color === 'undefined' || color === null) {
    return ''
  }

  return setAttributes([color + 10])
}

export function invert() {
  // Inverts the foreground and background colors.

  return `${ESC}[7m`
}

export function invertOff() {
  // Un-inverts the foreground and backgrund colors.

  return `${ESC}[27m`
}

export function startTrackingMouse() {
  return `${ESC}[?1002h`
}

export function stopTrackingMouse() {
  return `${ESC}[?1002l`
}

export function requestCursorPosition() {
  // Requests the position of the cursor.
  // Expect a stdin-result '\ESC[l;cR', where l is the line number (1-based),
  // c is the column number (also 1-based), and R is the literal character
  // 'R' (decimal code 82).

  return `${ESC}[6n`
}

export function enableAlternateScreen() {
  // Enables alternate screen:
  // "Xterm maintains two screen buffers.  The normal screen buffer allows
  // you to scroll back to view saved lines of output up to the maximum set
  // by the saveLines resource.  The alternate screen buffer is exactly as
  // large as the display, contains no additional saved lines."

  return `${ESC}[?1049h`
}

export function disableAlternateScreen() {
  return `${ESC}[?1049l`
}

export function measureColumns(text) {
  // Returns the number of columns the given text takes. Accounts for escape
  // codes (by not including them in the returned width).

  if (text.includes(ESC)) {
    text = text.replace(new RegExp(String.raw`${ESC}\[\??[0-9;]*.`, 'g'), '')
  }

  return wcwidth(text)
}

export function trimToColumns(text, cols) {
  // Trims off the end of the passed text so that its width doesn't exceed
  // the size passed in columns.

  let out = ''
  for (const char of text) {
    if (measureColumns(out + char) <= cols) {
      out += char
    } else {
      break
    }
  }
  return out
}

export function wrapToColumns(text, cols) {
  // Wraps a string into separate lines. Returns an array of strings, for
  // each line of the text.

  const lines = []
  const words = text.split(' ')

  let curLine = words[0]
  let curColumns = measureColumns(curLine)

  for (const word of words.slice(1)) {
    const wordColumns = measureColumns(word)
    if (curColumns + wordColumns > cols) {
      lines.push(curLine)
      curLine = word
      curColumns = wordColumns
    } else {
      curLine += ' ' + word
      curColumns += 1 + wordColumns
    }
  }

  lines.push(curLine)

  return lines
}

export function isANSICommand(buffer, code = null) {
  return (
    buffer[0] === 0x1b && buffer[1] === 0x5b &&
    (code ? buffer[buffer.length - 1] === code : true)
  )
}

export function interpret(text, scrRows, scrCols, {
  oldChars = null, oldLastChar = null,
  oldScrRows = null, oldScrCols = null,
  oldCursorRow = 1, oldCursorCol = 1, oldShowCursor = true
} = {}) {
  // Interprets the given ansi code, more or less.

  const blank = {
    attributes: [],
    char: ' '
  }

  const chars = new Array(scrRows * scrCols).fill(blank)

  if (oldChars) {
    for (let row = 0; row < scrRows && row < oldScrRows; row++) {
      for (let col = 0; col < scrCols && col < oldScrCols; col++) {
        chars[row * scrCols + col] = oldChars[row * oldScrCols + col]
      }
    }
  }

  let showCursor = oldShowCursor
  let cursorRow = oldCursorRow
  let cursorCol = oldCursorCol
  let attributes = []

  for (let charI = 0; charI < text.length; charI++) {
    const cursorIndex = (cursorRow - 1) * scrCols + (cursorCol - 1)

    if (text[charI] === ESC) {
      charI++

      if (text[charI] !== '[') {
        throw new Error('ESC not followed by [')
      }

      charI++

      // Selective control sequences (look them up) - we can just skip the
      // question mark.
      if (text[charI] === '?') {
        charI++
      }

      const args = []
      let val = ''
      while (isDigit(text[charI])) {
        val += text[charI]
        charI++

        if (text[charI] === ';') {
          charI++
          args.push(val)
          val = ''
          continue
        }
      }
      args.push(val)

      // CUP - Cursor Position (moveCursor)
      if (text[charI] === 'H') {
        cursorRow = parseInt(args[0])
        cursorCol = parseInt(args[1])
      }

      // SM - Set Mode
      if (text[charI] === 'h') {
        if (args[0] === '25') {
          showCursor = true
        }
      }

      // ED - Erase Display (clearScreen)
      if (text[charI] === 'J') {
        // ESC[2J - erase whole display
        if (args[0] === '2') {
          chars.fill(blank)
          charI += 3
          cursorCol = 1
          cursorRow = 1
        }

        // ESC[1J - erase to beginning
        else if (args[0] === '1') {
          for (let i = 0; i < cursorIndex; i++) {
            chars[i * 2] = ' '
            chars[i * 2 + 1] = []
          }
        }

        // ESC[0J - erase to end
        else if (args.length === 0 || args[0] === '0') {
          for (let i = cursorIndex; i < chars.length; i++) {
            chars[i * 2] = ' '
            chars[i * 2 + 1] = []
          }
        }
      }

      // RM - Reset Mode
      if (text[charI] === 'l') {
        if (args[0] === '25') {
          showCursor = false
        }
      }

      // SGR - Select Graphic Rendition
      if (text[charI] === 'm') {
        const removeAttribute = attr => {
          if (attributes.includes(attr)) {
            attributes = attributes.slice()
            attributes.splice(attributes.indexOf(attr), 1)
          }
        }

        for (const arg of args) {
          if (arg === '0') {
            attributes = []
          } else if (arg === '22') { // Neither bold nor faint
            removeAttribute('1')
            removeAttribute('2')
          } else if (arg === '23') { // Neither italic nor Fraktur
            removeAttribute('3')
            removeAttribute('20')
          } else if (arg === '24') { // Not underlined
            removeAttribute('4')
          } else if (arg === '25') { // Blink off
            removeAttribute('5')
          } else if (arg === '27') { // Inverse off
            removeAttribute('7')
          } else if (arg === '28') { // Conceal off
            removeAttribute('8')
          } else if (arg === '29') { // Not crossed out
            removeAttribute('9')
          } else if (arg === '39') { // Default foreground
            for (let i = 0; i < 10; i++) {
              removeAttribute('3' + i)
            }
          } else if (arg === '49') { // Default background
            for (let i = 0; i < 10; i++) {
              removeAttribute('4' + i)
            }
          } else {
            attributes = attributes.concat([arg])
          }
        }
      }

      continue
    }

    chars[cursorIndex] = {
      char: text[charI], attributes
    }

    // Some characters take up multiple columns, e.g. Japanese text. Take
    // this into consideration when drawing.
    const charColumns = wcwidth(text[charI])
    cursorCol += charColumns

    // If the character takes up 2+ columns, treat columns past the first
    // one (where the character is) as empty. (Note this is different from
    // "blank", which represents an empty space character ' '.)
    for (let i = 1; i < charColumns; i++) {
      chars[cursorIndex + i] = {char: '', attributes: []}
    }

    if (cursorCol > scrCols) {
      cursorCol = 1
      cursorRow++
    }
  }

  // SPOooooOOoky diffing! -------------
  //
  // - Search for series of differences. This means a collection of characters
  //   which have different text or attribute properties.
  //
  // - Figure out how to print these differences. Move the cursor to the beginning
  //   character's row/column, then print the differences.

  const newChars = chars

  const differences = []

  if (oldChars === null) {
    differences.push(0)
    differences.push(newChars.slice())
  } else {
    const charsEqual = (oldChar, newChar) => {
      if (oldChar.char !== newChar.char) {
        return false
      }

      let oldAttrs = oldChar.attributes.slice()
      let newAttrs = newChar.attributes.slice()

      while (newAttrs.length) {
        const attr = newAttrs.shift()
        if (oldAttrs.includes(attr)) {
          oldAttrs.splice(oldAttrs.indexOf(attr), 1)
        } else {
          return false
        }
      }

      oldAttrs = oldChar.attributes.slice()
      newAttrs = newChar.attributes.slice()

      while (oldAttrs.length) {
        const attr = oldAttrs.shift()
        if (newAttrs.includes(attr)) {
          newAttrs.splice(newAttrs.indexOf(attr), 1)
        } else {
          return false
        }
      }

      return true
    }

    let curChars = null

    for (let i = 0; i < chars.length; i++) {
      const oldChar = oldChars[i]
      const newChar = newChars[i]

      // TODO: Some sort of "distance" before we should clear curDiff?
      // It may take *less* characters if this diff and the next are merged
      // (entering a single character is smaller than the length of the code
      // used to move past that character). Probably not very significant of
      // an impact, though.
      if (charsEqual(oldChar, newChar)) {
        curChars = null
      } else {
        if (curChars === null) {
          curChars = []
          differences.push(i, curChars)
        }

        curChars.push(newChar)
      }
    }
  }

  // Character concatenation -----------

  let lastChar = oldLastChar || {
    char: '',
    attributes: []
  }

  const result = []

  for (let parse = 0; parse < differences.length; parse += 2) {
    const i = differences[parse]
    const chars = differences[parse + 1]

    const col = i % scrCols
    const row = (i - col) / scrCols
    result.push(moveCursor(row, col))

    for (const char of chars) {
      const newAttributes = (
        char.attributes.filter(attr => !(lastChar.attributes.includes(attr)))
      )

      const removedAttributes = (
        lastChar.attributes.filter(attr => !(char.attributes.includes(attr)))
      )

      // The only way to practically remove any character attribute is to
      // reset all of its attributes and then re-add its existing attributes.
      // If we do that, there's no need to add new attributes.
      if (removedAttributes.length) {
        result.push(resetAttributes())
        result.push(`${ESC}[${char.attributes.join(';')}m`)
      } else if (newAttributes.length) {
        result.push(`${ESC}[${newAttributes.join(';')}m`)
      }

      result.push(char.char)

      lastChar = char
    }
  }

  // If anything changed *or* the cursor moved, we need to put it back where
  // it was before:
  if (result.length || cursorCol !== oldCursorCol || cursorRow !== oldCursorRow) {
    result.push(moveCursor(cursorRow, cursorCol))
  }

  // If the cursor is visible and wasn't before, or vice versa, we need to
  // show that:
  if (showCursor && !oldShowCursor) {
    result.push(showCursor())
  } else if (!showCursor && oldShowCursor) {
    result.push(hideCursor())
  }

  return {
    oldChars: newChars.slice(),
    oldLastChar: Object.assign({}, lastChar),
    oldScrRows: scrRows,
    oldScrCols: scrCols,
    oldCursorRow: cursorRow,
    oldCursorCol: cursorCol,
    oldShowCursor: showCursor,
    screen: result.join('')
  }
}
